Skip to content

feat: add event engine WASM for OpenFeature track() support - #524

Open
vahidlazio wants to merge 4 commits into
mainfrom
feat/event-engine-wasm
Open

feat: add event engine WASM for OpenFeature track() support#524
vahidlazio wants to merge 4 commits into
mainfrom
feat/event-engine-wasm

Conversation

@vahidlazio

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a new WASM module (confidence_event_engine.wasm, 56KB) that batches OpenFeature track() events for server-side providers (JS, Java, Go, Python)
  • Uses the same lock-free SegQueue + bounded flush pattern as the resolver's AssignLogger — proven, no new patterns to audit
  • Providers call track_event() to queue events and bounded_flush_events() to drain up to 4MB batches, then POST to events.confidence.dev/v1/events:publish (matching Android SDK format)

New components

Component Purpose
confidence-event-engine/ Core Rust crate: EventLogger with lock-free batching, 8 unit tests
wasm/event-guest/ WASM guest binary (56KB, zero host imports, fully self-contained)
openfeature-provider/proto/confidence/events/v1/ Shared proto: Event, PublishEventsRequest
tests/event-engine-e2e/{go,js,python}/ E2E load tests + memory/perf benchmarks against real API

WASM exports

Export Input → Output Purpose
wasm_msg_guest_track_event Event → Void Push event into batch queue
wasm_msg_guest_bounded_flush_events Void → PublishEventsRequest Drain up to 4MB, return network-ready batch

Provider integration pattern

Provider.track(eventName, context, details)
  → merge context into payload under "context" key (matching Android SDK)
  → call wasm_msg_guest_track_event(Event{...})

Provider flush timer (~15s)
  → call wasm_msg_guest_bounded_flush_events(Void)
  → decode PublishEventsRequest from protobuf
  → serialize to JSON: { clientSecret, sdk, sendTime, events }
  → POST to events.confidence.dev/v1/events:publish

Performance (Apple M1 Max)

Provider Track throughput Per-event latency
Go (wazero) 1.68M events/sec 593ns
JS (Node WebAssembly) 302K events/sec 3.3µs
Python (wasmtime) 16K events/sec 62µs
  • Zero memory leaks confirmed across 50K+ event cycles
  • 10K events flush in a single 490KB batch in <10ms

Test plan

  • cargo test -p confidence-event-engine — 8 unit tests (size limits, cross-flush persistence, data integrity)
  • cargo build -p event-guest --target wasm32-unknown-unknown --profile wasm — WASM compiles (56KB)
  • cargo clippy + cargo fmt — clean on both crates
  • Go e2e: load WASM → track 1K events → flush → POST to real events API (HTTP 200)
  • JS e2e: same flow, zero external dependencies
  • Python e2e: same flow via wasmtime
  • Go memory tests: WASM linear memory stable, Go heap stable, no leaks
  • Go benchmarks: track_event 593ns/op, flush scales linearly
  • Existing resolver WASM still builds and works (no regressions)

🤖 Generated with Claude Code

Comment thread tests/event-engine-e2e/go/main.go Outdated
Comment thread Dockerfile
COPY confidence-cloudflare-resolver/Cargo.toml ./confidence-cloudflare-resolver/
COPY wasm-msg/Cargo.toml ./wasm-msg/
COPY wasm/rust-guest/Cargo.toml ./wasm/rust-guest/
COPY wasm/event-guest/Cargo.toml ./wasm/event-guest/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also make sure the event engine is included in the Docker stages that actually run from the final all target? I guess copying the crate into the build context is the first step, but since this PR adds a committed confidence_event_engine.wasm, I just want to make sure CI also builds/lints/tests the standalone module rather than only making its sources available.

Comment thread wasm/confidence_event_engine.wasm Outdated
Comment thread confidence-event-engine/Cargo.toml Outdated
Comment thread wasm/event-guest/src/lib.rs Outdated
Comment thread wasm/event-guest/src/lib.rs Outdated
Ok(VOID)
}

fn bounded_flush_events(_request: Void) -> WasmResult<PublishEventsRequest> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember exactly how we handle the flushing of logs in the resolver but I think it makes sense to have the event flushing semantics intentionally match the resolver log flushing path? I think bounded_flush_events using the same bounded, drain-available approach as bounded_flush_logs makes sense, but I just want to make sure we’re not accidentally diverging on bounded vs unbounded flush behavior for the new WASM module.

Comment thread openfeature-provider/proto/confidence/events/v1/events_api.proto Outdated
Comment thread openfeature-provider/proto/confidence/events/v1/events_api.proto Outdated
Comment thread openfeature-provider/proto/confidence/events/v1/events_api.proto Outdated
Comment thread openfeature-provider/proto/confidence/events/v1/events_api.proto Outdated
@vahidlazio
vahidlazio force-pushed the feat/event-engine-wasm branch from 400dd8e to 066d19a Compare August 21, 2026 14:11
Comment thread wasm/event-guest/src/lib.rs Outdated
}
}

if req.value != 0.0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't we need to be able to differentiate absence of a value with 0.0?


static EVENT_LOGGER: LazyLock<EventLogger> = LazyLock::new(EventLogger::new);

fn build_payload(req: &TrackEventRequest) -> Option<Struct> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a some tests and/or note + readme for the payload merge behavior here? I guess value and context intentionally win over same-named fields in data, but I just want to make sure that collision behavior is defined since OpenFeature custom data can contain arbitrary keys.

Comment thread Dockerfile
# ==============================================================================
# Build confidence-event-engine
# ==============================================================================
FROM rust-test-base AS confidence-event-engine.build

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also reference these new event-engine stages from the final all target further down in this file? I thing Docker won’t run this stage family in the main build unless something copies from confidence-event-engine.test / .lint there.

Comment thread Dockerfile
# ==============================================================================
# Build wasm/event-guest WASM
# ==============================================================================
FROM wasm-deps AS wasm-event-guest.build

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also reference this event WASM stage from the final all target further down in this file? Docker won’t force wasm-event-guest.build / .lint / .artifact during the main build unless all copies from them.

@vahidlazio
vahidlazio force-pushed the feat/event-engine-wasm branch 2 times, most recently from 4f6269c to ecd5258 Compare August 24, 2026 10:30

// EventResolver wraps a WASM event engine instance and exposes TrackEvent
// and FlushEvents operations using the wasm-msg protocol.
type EventResolver struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this called Resolver? EventTracker?

@@ -0,0 +1,207 @@
// Package event_resolver provides a WASM-based event engine for tracking
// and flushing Confidence events via the wasm-msg protocol.
package event_resolver

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event_tracking?


// call implements the wasm-msg protocol: marshal request into a Request envelope,
// allocate WASM memory, write, call the export, read the Response envelope, free.
func (er *EventResolver) call(fnName string, request proto.Message, response proto.Message) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these classes (this class and the resolver) where we do wasm integration we now have this duplication. Does it make sense to break it out in to an abstract class? (maybe that's not a Go thing, but anyway, code reuse)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i didn't want to touch the resolver, since we might switch to state to wasm etc, but if we want to use same code, i'd do that in a separate PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds completely fair. didn't think about the upcoming plans.

}
req.Header.Set("Content-Type", "application/json")

resp, err := p.httpClient.Do(req)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did we choose http json here instead of grpc (which we use for the apply logs)?


// Initialize event resolver if WASM bytes are provided
if len(options.eventWasmBytes) > 0 {
eventResolver, err := er.NewEventResolver(options.eventWasmBytes, options.useWasmInterpreter)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Resolver we have these "RecoveringResolvers" that wrap the resolvers and support reconstructing/restarting the WASM if a problem occurs.
I don't expect us to salvage the events in a broken wasm instance but I think it makes sense to be able to keep the provider functioning.

Comment thread openfeature-provider/go/confidence/provider.go
Comment on lines +922 to +928
def track(
self,
event_name: str,
context: Optional[EvaluationContext] = None,
value: Optional[float] = None,
data: Optional[Dict[str, Any]] = None,
) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does not match the python provider interface declaration

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming across this file -> event_tracker

logger = logging.getLogger(__name__)

# Exception types that indicate a WASM crash requiring reload
WasmCrashError = (RuntimeError, WasmTrap)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this narrower than all RuntimeErrors? I guess _consume_response also raises RuntimeError for a clean guest error envelope, so this path would reload the event WASM and drop the buffered instance even though the WASM may still be healthy.

Python (review r3843953168): track() did not match FeatureProvider.track. It
took (event_name, context, value, data); the interface declares
(tracking_event_name, evaluation_context, tracking_event_details). Renamed the
parameters and now unpack value/attributes from TrackingEventDetails. Confirmed
openfeature.track.TrackingEventDetails exists in the pinned openfeature-sdk
>=0.10.0 (the local venv has a stale 0.8.4 that predates it).

JS: same class of problem. The third parameter was a loose inline type
`{ value?: number; [key: string]: any }` rather than the SDK's
TrackingEventDetails. It typechecked only because the loose type is permissive
enough to accept it. Now imports and uses the real exported type, and the first
parameter is named trackingEventName to match.

Go and Java were already conformant and are unchanged — Go has a compile-time
`_ openfeature.Tracker = (*LocalResolverProvider)(nil)` assertion and Java's
track() is an @OverRide. This is the second signature mismatch found in review
(Go's was fixed earlier), so the conformance checks matter: TypeScript's
structural typing and Python's duck typing both let a wrong signature compile.

Also corrects the README: TrackingEventDetails.value is Optional[float] in
Python, so Python preserves an explicit 0 like Java and JS. Go remains the only
provider that cannot distinguish 0 from unset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vahidlazio
vahidlazio force-pushed the feat/event-engine-wasm branch from 8d1de9d to 8fedcca Compare August 24, 2026 13:42
vahidlazio and others added 2 commits August 24, 2026 15:50
Two review comments on event_resolver.py.

Reload trigger was too broad — the same defect already fixed in Go and JS, and
Python was missed. WasmCrashError was (RuntimeError, WasmTrap), but the module
itself raises RuntimeError for a clean guest error envelope, so a politely
reported guest error was treated as a crash: the instance got rebuilt and every
event buffered inside it thrown away. Now EventEngineError (a RuntimeError
subclass, so existing callers still work) carries guest-reported errors and is
propagated, while WasmCrashError is narrowed to (WasmTrap, WasmtimeError) —
actual faults. Mirrors errWasmFatal in the Go tracker.

Also added the resp_ptr == 0 guard in flush_events, matching the guards added to
Go and Java: falling through would read the length prefix at addr-4.

Renamed event_resolver.py -> event_tracker.py and EventResolver -> EventTracker,
matching the Go provider's event_tracking package. It tracks events; it does not
resolve anything.

Verified in Docker rather than the local venv, which has a stale
openfeature-sdk 0.8.4 that predates openfeature.track: the Docker stage installs
the pinned 0.10.0 and all 90 Python tests pass, confirming both the new import
and the rename.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three of the four providers had a track() that did not match its SDK's declared
interface (Go, then Python and JS). Go catches this at compile time via
`_ openfeature.Tracker = (*LocalResolverProvider)(nil)` and Java via @OverRide,
so those two broke loudly. Python's duck typing and TypeScript's structural
typing both let a wrong signature through silently — Python had no guard at all.

Adds tests/test_event_tracking.py:
- asserts ConfidenceProvider.track's parameter names match
  AbstractProvider.track via inspect.signature, so drift fails CI
- asserts the documented no-op call shapes don't raise
- asserts EventEngineError is NOT in WasmCrashError, locking in the narrow
  reload trigger — a guest-reported error must not discard buffered events
- covers the eventDefinitions/ prefix, buffer draining, and that an explicit
  value of 0 survives (Python's TrackingEventDetails.value is Optional[float],
  so unlike Go it can represent it)

Also copies the event WASM into the Python Docker stage, mirroring the resolver,
so those tests have a module to load.

Verified in Docker with the pinned openfeature-sdk 0.10.0: 97 tests pass, up
from 90.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Events buffered when the process dies uncleanly are lost. Shutdown drains up
to 100 batches, but a `SIGKILL` skips that entirely.

## Known provider differences

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we ship this we should include this part in the main (as well as Go) readme.


// A missing export means the module is not what we expect: that is fatal and
// must reload.
func TestFatalErrorReloadsInstance(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add tests that asserts that we can call correct functions before and after the crash?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants